昨天我們聊到到了 JavaScript 中的事件迴圈,文中末段提到了透過 IIFE 的解決方法:
for (var i = 1; i <= 5; i++) {
(function (x) {
setTimeout(function () {
console.log(x)
}, 1000 * x)
})(i)
}
仔細想想蠻奇怪的對吧?原本的版本中,console.log()
都指到同一個變數 i
,為什麼在經過一層函式後,當下 i
的數值就能被保留住呢?
這就關係到今天的主題 - 閉包(Closure)。
本系列文已經重新編校彙整編輯成冊,並正式出版囉!
《前端三十:從 HTML 到瀏覽器渲染的前端開發者必備心法》好評販售中!
喜歡我文章內容的讀者們,歡迎您 前往購買 支持!
閉包這個名詞,對稍有經驗的開發者應該都不陌生,但具體來說是指什麼呢?
一如既往的,讓我們從範例程式出發:
function add(num) {
function func(x = 0) {
return num + x
}
return func
}
let addFive = add(5)
console.log(addFive(8)) // 8 + 5 = 13
add
是一個接收參數 num
、回傳函式 func
的函式。由於 JavaScript 有自動回收機制,理論上在函式執行完畢後會將函式所佔用的記憶體空間釋放;但在此處的範例中,可以看到參數 num
在 add
執行完畢後,仍然可以被回傳出來的函式使用,沒有跟著 add 一起被回收掉。
這種把外層變數包在內層使用的方法,也就是耳熟能詳的閉包。
初學者可能會對「回傳函式的函式」感到有些困惑,但從 JavaScript 的基本型別來看是非常正常的事情;更詳細的說明會在後續系列文中提到,這邊暫且先大概知道就好囉。
讓我們再看一個稍微複雜的例子:
loadPicture() {
let count = this.pageContent.length
const load = (target, resolve) => {
const counter = () => (--count ? count : resolve())
let img = new Image()
img.onload = counter
img.onerror = counter
img.src = target.url
}
return new Promise(resolve => {
if (!count) return resolve()
this.pageContent.forEach(target => load(target, resolve))
})
}
注意觀察 count
變數,它在整個 loadPicture
函式建立時便被宣告、賦值,並在圖片讀取的 onload、onerror
時透過事件監聽 counter
逐次減 1,最後判斷當 count
為 0 時 resolve
回傳 的 Promise 物件。
閉包發生的時機是在函式建立的時候,每當新的函式被建立出來,它會紀錄它所在的位置的 執行環境,並記錄外層的 作用域鏈。
剛提到了執行環境(Execution Context,EC),EC 指的是 Javascript 底層在程式準備執行時,針對「全域」及「函式」所建立的一個物件,主要是儲存了:
借一下JavaScript: Understanding the Weird Parts 的課程影片截圖;這部課程超好看,大大大大推!
同樣的,參考以下的範例程式:
var a = 0
function b() {
var a = 10
function c() {
console.log(a)
}
c()
}
b() // 10
可以想像 EC 會長成這樣:
當執行時,依序會進行下面的事情:
Global EC
,預留了變數 a
的空間,及函式 b
的作用域鏈b
,建立 function b() EC
,預留了區域變數 a
的空間,及函式 c
的作用域鏈b()
,區域變數 a
賦值 a = 10
,接著呼叫函式 c()
c
,建立 function c() EC
,並依照作用域鏈找到 function b() EC
中的區域變數 a
c()
,呼叫 console.log(a)
;印出 10c()
執行結束,消除 function c() EC
b()
執行結束,消除 function b() EC
一般的情境中,依照呼叫的順序、依序建立 EC,並在執行完成後將 EC 消除,釋放記憶體空間。那麼當閉包發生時 EC 會有什麼改變呢?
修改前述的例子,讓函式 b
回傳函式 c
:
var a = 0
function b() {
var a = 10
function c() {
console.log(a)
}
return c
}
var func = b()
console.log(a) // 0
func() // 10
Global EC
,預留了變數 a
、func
的空間,及函式 b
的作用域鏈Global EC
,變數 a
賦值 a = 0
,呼叫函式 b()
b
,建立 function b()
EC,預留了區域變數 a
的空間,及函式 c
的作用域鏈b()
,區域變數 a
賦值 a = 10
,回傳函式 c
b
執行結束,消除 function b() EC
執行到這時,
function b() EC
內的a
被回傳的函數 c 閉包了!
func
賦值成函式 b()
的執行結果 - 函式 c
console.log(a)
,印出 Global EC
中的 a
: 1;接著呼叫 func()
c
,建立 function c() EC
,並依照作用域鏈找到 function b() EC
中的區域變數 a
func()
,執行 console.log(a)
;印出 10func()
執行結束,消除 function c() EC
由於閉包,在 b()
執行結束時,其中的區域變數 a
並未跟著 function b() EC
一起消失,而是留給了 function c() EC
的參照使用,直到參照消失,其佔用的記憶體才會跟著一起釋放。因此在使用閉包時需要注意,冗餘的閉包只會造成記憶體的負擔!
「閉包」這個詞其實有許多種定義,本文撰寫時所採用的說法,是較偏向實際開發時會考慮的情境,也就是將「內層函式引用外層參數」的行為稱呼為閉包。
但在 MDN 及 Wiki 中都有提到類似的定義:「閉包是由函式和與其相關的參照環境組合而成的實體」,而實際上在 JavaScript 底層的行為中,每一個函式建立時都會紀錄它所在的作用域環境,也因此可以說,所有函式是閉包。
從 昨天 提過的範例程式出發,我們深入到了 JavaScript 中著名的特性 - 閉包,並藉由理解執行環境及作用域鏈,進一步的逐步拆解閉包函式的執行過程。
認識了執行環境之後,JavaScript 中許多晦澀難懂的主題,也就漸漸會變得沒那麼難以理解喔!那麼今天就先到這邊吧,我們大家明天見~
筆者
Gary
半路出家網站工程師;半生熟的前端加上一點點的後端。
喜歡音樂,喜歡學習、分享,也喜歡當個遊戲宅。相信一切安排都是最好的路。
可以問一下文章提到的「 You don't know JS 的課程影片」,可以提供連結或reference嗎~
啊抱歉我附註錯了;圖片是來自 JavaScript: Understanding the Weird Parts 的課程片段。
謝謝你的回應~
哈哈,因為我想說我寫的是 You don't know JS ,怎麼沒看過這個 XD